Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 66412c74389b272bbd7ced2f5071396e5031a59f


Parents : 61ca849
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-14T19:36:10-05:00

feat(setup): add new packages 'lxmfy' and 'websockets' to cx_setup.py; refactor JSON output handling in build_community_interfaces_json.py; improve docstring clarity in move_wheels.py; update datetime import in slsa-predicate.py; enhance performance benchmarks with clearer argument passing; and refactor various test cases for improved readability and consistency.

Changes

38 files changed, 1384 insertions(+), 101 deletions(-)


Diff

diff --git a/cx_setup.py b/cx_setup.py
index f3050674..76aea6e8 100644
--- a/cx_setup.py
+++ b/cx_setup.py
@@ -34,6 +34,8 @@ packages = [
"RNS.Interfaces",
"LXMF",
"LXST",
+ "lxmfy",
+ "websockets",
"pycparser",
"cffi",
"ply",

diff --git a/scripts/build_community_interfaces_json.py b/scripts/build_community_interfaces_json.py
index 4d9a2b09..51327451 100644
--- a/scripts/build_community_interfaces_json.py
+++ b/scripts/build_community_interfaces_json.py
@@ -85,7 +85,8 @@ def main() -> int:
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
- json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8",
+ json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
)
print(f"Wrote {len(out_list)} interfaces to {args.output}")
return 0

diff --git a/scripts/ci/slsa-predicate.py b/scripts/ci/slsa-predicate.py
index ef1fe649..ce6c3234 100644
--- a/scripts/ci/slsa-predicate.py
+++ b/scripts/ci/slsa-predicate.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import json
import os
-from datetime import datetime, timezone
+from datetime import UTC, datetime
def _source_uri() -> str:
@@ -55,7 +55,7 @@ def main() -> None:
run_id = os.environ.get("GITHUB_RUN_ID", "")
attempt = os.environ.get("GITHUB_RUN_ATTEMPT", "1")
workflow = os.environ.get("GITHUB_WORKFLOW", "")
- started = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
internal = {}
if workflow:

diff --git a/scripts/move_wheels.py b/scripts/move_wheels.py
index 43cf3a4b..1525b89d 100644
--- a/scripts/move_wheels.py
+++ b/scripts/move_wheels.py
@@ -1,5 +1,6 @@
-"""Move Poetry-built wheels from dist/ to python-dist/ to avoid conflicts
-with Electron build artifacts.
+"""Move Poetry-built wheels from ``dist/`` into ``python-dist/``.
+
+Avoids filename clashes with Electron build outputs.
"""
import shutil

diff --git a/tests/backend/memory_benchmarks.py b/tests/backend/memory_benchmarks.py
index e997e70a..f50a54de 100644
--- a/tests/backend/memory_benchmarks.py
+++ b/tests/backend/memory_benchmarks.py
@@ -177,7 +177,8 @@ class PerformanceBenchmarker:
def run_trim():
for _ in range(runs):
self.db.announces.trim_announces_for_aspect(
- aspect, max(1, seed_count // 2)
+ aspect,
+ max(1, seed_count // 2),
)
self.record_benchmark(

diff --git a/tests/backend/test_access_attempts_dao.py b/tests/backend/test_access_attempts_dao.py
index 00078323..0a727781 100644
--- a/tests/backend/test_access_attempts_dao.py
+++ b/tests/backend/test_access_attempts_dao.py
@@ -65,7 +65,13 @@ def test_count_matches_list_for_no_filters(dao):
def test_search_filters(dao):
ih = _id_hash()
dao.insert(
- ih, "192.168.99.1", "UniqueSearchUA", LOGIN_PATH, "POST", "success", "detail-x",
+ ih,
+ "192.168.99.1",
+ "UniqueSearchUA",
+ LOGIN_PATH,
+ "POST",
+ "success",
+ "detail-x",
)
found = dao.list_attempts(search="UniqueSearchUA", limit=50)
assert any(r["user_agent"] == "UniqueSearchUA" for r in found)

diff --git a/tests/backend/test_access_attempts_enforcement.py b/tests/backend/test_access_attempts_enforcement.py
index 8d9612a1..7cced2fc 100644
--- a/tests/backend/test_access_attempts_enforcement.py
+++ b/tests/backend/test_access_attempts_enforcement.py
@@ -30,7 +30,10 @@ from meshchatx.src.backend.database.access_attempts import (
def _make_req(
- ip: str, ua: str, method: str = "POST", xff: str | None = None,
+ ip: str,
+ ua: str,
+ method: str = "POST",
+ xff: str | None = None,
) -> SimpleNamespace:
h = {"User-Agent": ua}
if xff is not None:

diff --git a/tests/backend/test_announce_fuzzing.py b/tests/backend/test_announce_fuzzing.py
index 45b505d4..beae72df 100644
--- a/tests/backend/test_announce_fuzzing.py
+++ b/tests/backend/test_announce_fuzzing.py
@@ -54,7 +54,12 @@ def test_announce_max_stored_config_fuzz(max_stored):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
- reticulum, identity, b"new_dest", "lxmf.delivery", b"app_data", b"packet",
+ reticulum,
+ identity,
+ b"new_dest",
+ "lxmf.delivery",
+ b"app_data",
+ b"packet",
)
mock_db.announces.upsert_announce.assert_called_once()

diff --git a/tests/backend/test_announce_limits.py b/tests/backend/test_announce_limits.py
index ca3f9b73..caafb8e8 100644
--- a/tests/backend/test_announce_limits.py
+++ b/tests/backend/test_announce_limits.py
@@ -39,7 +39,12 @@ def test_trim_called_when_over_max_stored(mock_db, mock_config):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
- reticulum, identity, b"new_dest", "lxmf.delivery", b"app_data", b"packet_hash",
+ reticulum,
+ identity,
+ b"new_dest",
+ "lxmf.delivery",
+ b"app_data",
+ b"packet_hash",
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -57,7 +62,12 @@ def test_no_trim_without_config(mock_db):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
- reticulum, identity, b"dest", "lxmf.delivery", b"app_data", b"packet",
+ reticulum,
+ identity,
+ b"dest",
+ "lxmf.delivery",
+ b"app_data",
+ b"packet",
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -74,7 +84,12 @@ def test_max_stored_none_skips_trim(mock_db, mock_config):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
- reticulum, identity, b"dest", "lxmf.delivery", b"app_data", b"packet",
+ reticulum,
+ identity,
+ b"dest",
+ "lxmf.delivery",
+ b"app_data",
+ b"packet",
)
mock_db.announces.trim_announces_for_aspect.assert_not_called()
@@ -147,7 +162,12 @@ def test_announce_handles_none_packet_hash(mock_db):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
- reticulum, identity, b"dest", "lxmf.delivery", b"app_data", None,
+ reticulum,
+ identity,
+ b"dest",
+ "lxmf.delivery",
+ b"app_data",
+ None,
)
mock_db.announces.upsert_announce.assert_called_once()

diff --git a/tests/backend/test_announce_manager_extended.py b/tests/backend/test_announce_manager_extended.py
index ffab9fc6..53a715c2 100644
--- a/tests/backend/test_announce_manager_extended.py
+++ b/tests/backend/test_announce_manager_extended.py
@@ -26,7 +26,12 @@ def test_upsert_announce(mock_db):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
- reticulum, identity, b"dest_hash", "aspect", b"app_data", b"packet_hash",
+ reticulum,
+ identity,
+ b"dest_hash",
+ "aspect",
+ b"app_data",
+ b"packet_hash",
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -54,7 +59,9 @@ def test_get_filtered_announces_count(mock_db):
manager = AnnounceManager(mock_db)
mock_db.provider.fetchone.return_value = {"count": 5}
count = manager.get_filtered_announces_count(
- aspect="test", query="q", blocked_identity_hashes=["b1"],
+ aspect="test",
+ query="q",
+ blocked_identity_hashes=["b1"],
)
assert count == 5

diff --git a/tests/backend/test_archiver_manager_extended.py b/tests/backend/test_archiver_manager_extended.py
index a460ba27..fdaa2f57 100644
--- a/tests/backend/test_archiver_manager_extended.py
+++ b/tests/backend/test_archiver_manager_extended.py
@@ -52,7 +52,8 @@ def test_archive_page_enforce_max_versions(mock_db):
# Should delete the 6th version (index 5)
mock_db.provider.execute.assert_any_call(
- "DELETE FROM archived_pages WHERE id = ?", (6,),
+ "DELETE FROM archived_pages WHERE id = ?",
+ (6,),
)
@@ -69,5 +70,6 @@ def test_archive_page_enforce_storage_limit(mock_db):
manager.archive_page("dest", "/path", "content", max_storage_gb=1)
mock_db.provider.execute.assert_any_call(
- "DELETE FROM archived_pages WHERE id = ?", (10,),
+ "DELETE FROM archived_pages WHERE id = ?",
+ (10,),
)

diff --git a/tests/backend/test_community_interfaces_directory.py b/tests/backend/test_community_interfaces_directory.py
index 4fd96735..d5834726 100644
--- a/tests/backend/test_community_interfaces_directory.py
+++ b/tests/backend/test_community_interfaces_directory.py
@@ -202,7 +202,8 @@ def test_transform_tcp_with_backbone_in_config_and_identity():
st.fixed_dictionaries(
{
"id": st.one_of(
- st.none(), st.integers(min_value=-1000, max_value=10000),
+ st.none(),
+ st.integers(min_value=-1000, max_value=10000),
),
"name": st.text(max_size=40),
"type": st.sampled_from(["backbone", "tcp", "i2p", "rnode", ""]),

diff --git a/tests/backend/test_concurrency_stress.py b/tests/backend/test_concurrency_stress.py
index cd11ba87..f7b284c7 100644
--- a/tests/backend/test_concurrency_stress.py
+++ b/tests/backend/test_concurrency_stress.py
@@ -111,7 +111,9 @@ class TestConcurrencyStress(unittest.TestCase):
"SELECT COUNT(*) as count FROM lxmf_messages",
)["count"]
self.assertEqual(
- total, 5 * 50, "Total messages inserted doesn't match expected count",
+ total,
+ 5 * 50,
+ "Total messages inserted doesn't match expected count",
)
print(f"Stress test completed. Total messages inserted: {total}")
@@ -132,7 +134,8 @@ class TestConcurrencyStress(unittest.TestCase):
id_thread = threading.Thread(target=identity_worker)
db_thread = threading.Thread(
- target=self.db_writer_worker, args=("id_collision",),
+ target=self.db_writer_worker,
+ args=("id_collision",),
)
id_thread.start()
@@ -152,7 +155,9 @@ class TestConcurrencyStress(unittest.TestCase):
"SELECT COUNT(*) as count FROM lxmf_messages",
)["count"]
self.assertEqual(
- total_messages, 50, "Should have inserted 50 messages during collision test",
+ total_messages,
+ 50,
+ "Should have inserted 50 messages during collision test",
)

diff --git a/tests/backend/test_dao_fuzzing.py b/tests/backend/test_dao_fuzzing.py
index c3fc2701..64442410 100644
--- a/tests/backend/test_dao_fuzzing.py
+++ b/tests/backend/test_dao_fuzzing.py
@@ -226,7 +226,12 @@ class TestMiscDAOFuzzing:
max_examples=40,
)
def test_add_notification_never_crashes(
- self, db, ntype, remote_hash, title, content,
+ self,
+ db,
+ ntype,
+ remote_hash,
+ title,
+ content,
):
db.misc.add_notification(ntype, remote_hash, title, content)
notifications = db.misc.get_notifications()
@@ -463,7 +468,9 @@ class TestMapDrawingsDAOFuzzing:
st.builds(
json.dumps,
st.dictionaries(
- st.text(max_size=10), st.text(max_size=50), max_size=10,
+ st.text(max_size=10),
+ st.text(max_size=50),
+ max_size=10,
),
),
),
@@ -572,7 +579,11 @@ class TestMessageHandlerFuzzing:
max_examples=40,
)
def test_get_conversation_messages_never_crashes(
- self, handler, dest, after_id, before_id,
+ self,
+ handler,
+ dest,
+ after_id,
+ before_id,
):
results = handler.get_conversation_messages(
"local_hash",

diff --git a/tests/backend/test_database_evolution.py b/tests/backend/test_database_evolution.py
index 21f6faaf..76a640ef 100644
--- a/tests/backend/test_database_evolution.py
+++ b/tests/backend/test_database_evolution.py
@@ -17,7 +17,9 @@ class TestDatabaseMigration(unittest.TestCase):
self.identity_hash = "deadbeef"
self.legacy_config_dir = os.path.join(self.test_dir, "legacy_config")
self.legacy_db_subdir = os.path.join(
- self.legacy_config_dir, "identities", self.identity_hash,
+ self.legacy_config_dir,
+ "identities",
+ self.identity_hash,
)
os.makedirs(self.legacy_db_subdir, exist_ok=True)
self.legacy_db_path = os.path.join(self.legacy_db_subdir, "database.db")
@@ -119,12 +121,15 @@ class TestDatabaseMigration(unittest.TestCase):
def test_migration_evolution(self):
migrator = LegacyMigrator(
- self.db.provider, self.legacy_config_dir, self.identity_hash,
+ self.db.provider,
+ self.legacy_config_dir,
+ self.identity_hash,
)
# Check if should migrate
self.assertTrue(
- migrator.should_migrate(), "Should detect legacy database for migration",
+ migrator.should_migrate(),
+ "Should detect legacy database for migration",
)
# Perform migration
@@ -136,7 +141,8 @@ class TestDatabaseMigration(unittest.TestCase):
print(f"Config rows: {config_rows}")
config_val = self.db.provider.fetchone(
- "SELECT value FROM config WHERE key = ?", ("legacy_key",),
+ "SELECT value FROM config WHERE key = ?",
+ ("legacy_key",),
)
self.assertIsNotNone(config_val, "legacy_key should have been migrated")
self.assertEqual(config_val["value"], "legacy_value")
@@ -147,7 +153,8 @@ class TestDatabaseMigration(unittest.TestCase):
self.assertEqual(ann_count, 1)
msg = self.db.provider.fetchone(
- "SELECT * FROM lxmf_messages WHERE hash = ?", ("msg1",),
+ "SELECT * FROM lxmf_messages WHERE hash = ?",
+ ("msg1",),
)
self.assertIsNotNone(msg)
self.assertEqual(msg["title"], "Old Title")

diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py
index eb765a5f..c6853e4c 100644
--- a/tests/backend/test_incoming_call_policy.py
+++ b/tests/backend/test_incoming_call_policy.py
@@ -128,7 +128,9 @@ def test_contacts_only_accepts_matching_contact(policy_app):
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
CALLER_HASH_HEX,
)
- policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
+ policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
+ caller
+ )
policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called()
async_utils.run_async.assert_called_once()
@@ -175,7 +177,9 @@ def test_when_policy_off_stranger_rings(policy_app):
_run_incoming(policy_app, caller)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
- policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
+ policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
+ caller
+ )
async_utils.run_async.assert_called_once()

diff --git a/tests/backend/test_integrity_extensive.py b/tests/backend/test_integrity_extensive.py
index 3f9c26f6..0a85548e 100644
--- a/tests/backend/test_integrity_extensive.py
+++ b/tests/backend/test_integrity_extensive.py
@@ -50,7 +50,9 @@ class TestIntegrityManagerExtensive(unittest.TestCase):
f.write(bytes(range(256)))
# log2(256) = 8
self.assertAlmostEqual(
- self.manager._calculate_entropy(max_entropy_file), 8.0, places=5,
+ self.manager._calculate_entropy(max_entropy_file),
+ 8.0,
+ places=5,
)
@settings(suppress_health_check=[HealthCheck.too_slow], deadline=None)
@@ -125,7 +127,8 @@ class TestIntegrityManagerExtensive(unittest.TestCase):
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.touch()
self.assertTrue(
- self.manager._should_ignore(str(rel_path)), f"Failed to ignore {v}",
+ self.manager._should_ignore(str(rel_path)),
+ f"Failed to ignore {v}",
)
def test_critical_file_protection(self):

diff --git a/tests/backend/test_lxmf_communication.py b/tests/backend/test_lxmf_communication.py
index bebfab1b..a160898b 100644
--- a/tests/backend/test_lxmf_communication.py
+++ b/tests/backend/test_lxmf_communication.py
@@ -1,5 +1,4 @@
-"""
-Integration tests for LXMF messaging and Reticulum communication.
+"""Integration tests for LXMF messaging and Reticulum communication.
Covers stamp proof-of-work, message packing/unpacking, signature validation,
delivery pipelines (direct, opportunistic, propagated), propagation node
@@ -86,8 +85,8 @@ def _cleanup():
# 1. Stamp proof-of-work (in-process, no Reticulum needed)
# ────────────────────────────────────────────────────────────────
-class TestStampSolving:
+class TestStampSolving:
def test_workblock_deterministic(self):
material = os.urandom(32)
wb1 = LXStamper.stamp_workblock(material, expand_rounds=10)
@@ -137,19 +136,22 @@ class TestStampSolving:
def test_propagation_node_stamp_rounds(self):
mid = os.urandom(32)
stamp, value = LXStamper.generate_stamp(
- mid, stamp_cost=2,
+ mid,
+ stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
assert stamp is not None
wb = LXStamper.stamp_workblock(
- mid, expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
+ mid,
+ expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
assert LXStamper.stamp_valid(stamp, 2, wb)
def test_peering_key_generation_and_validation(self):
peer_id = os.urandom(32)
stamp, _ = LXStamper.generate_stamp(
- peer_id, stamp_cost=2,
+ peer_id,
+ stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PEERING,
)
assert LXStamper.validate_peering_key(peer_id, stamp, 2)
@@ -158,18 +160,21 @@ class TestStampSolving:
peer_a = os.urandom(32)
peer_b = os.urandom(32)
stamp, _ = LXStamper.generate_stamp(
- peer_a, stamp_cost=8,
+ peer_a,
+ stamp_cost=8,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PEERING,
)
assert not LXStamper.validate_peering_key(peer_b, stamp, 8)
def test_pn_stamp_valid_transient_data(self):
from LXMF.LXMessage import LXMessage
+
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
fake_lxm = os.urandom(overhead + 64)
t_id = RNS.Identity.full_hash(fake_lxm)
stamp, _ = LXStamper.generate_stamp(
- t_id, stamp_cost=2,
+ t_id,
+ stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
td = fake_lxm + stamp
@@ -181,6 +186,7 @@ class TestStampSolving:
def test_pn_stamp_rejected_bad_stamp(self):
from LXMF.LXMessage import LXMessage
+
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
fake_lxm = os.urandom(overhead + 64)
bad_stamp = os.urandom(LXStamper.STAMP_SIZE)
@@ -190,13 +196,15 @@ class TestStampSolving:
def test_pn_stamp_batch_validation(self):
from LXMF.LXMessage import LXMessage
+
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
items = []
for _ in range(5):
fake = os.urandom(overhead + 64)
t_id = RNS.Identity.full_hash(fake)
stamp, _ = LXStamper.generate_stamp(
- t_id, stamp_cost=2,
+ t_id,
+ stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
items.append(fake + stamp)
@@ -222,8 +230,8 @@ class TestStampSolving:
# 2. LXMessage protocol (subprocess, real crypto)
# ────────────────────────────────────────────────────────────────
-class TestLXMessageProtocol:
+class TestLXMessageProtocol:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_pack_unpack_roundtrip(self):
@@ -514,8 +522,8 @@ class TestLXMessageProtocol:
# 3. LXMRouter delivery pipeline (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestLXMRouterDelivery:
+class TestLXMRouterDelivery:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_direct_delivery_via_lxmf_delivery(self):
@@ -840,8 +848,8 @@ class TestLXMRouterDelivery:
# 4. Propagation node operations (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestPropagationNode:
+class TestPropagationNode:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_enable_disable_propagation(self):
@@ -1031,8 +1039,8 @@ class TestPropagationNode:
# 5. LXMessage state machine and callbacks (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestMessageStateAndCallbacks:
+class TestMessageStateAndCallbacks:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_message_states_enum(self):
@@ -1141,8 +1149,8 @@ class TestMessageStateAndCallbacks:
# 6. Multi-field message integrity (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestMessageFieldIntegrity:
+class TestMessageFieldIntegrity:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_fields_survive_roundtrip(self):
@@ -1232,8 +1240,8 @@ class TestMessageFieldIntegrity:
# 7. URI ingestion (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestURIIngestion:
+class TestURIIngestion:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_paper_uri_roundtrip(self):
@@ -1284,8 +1292,8 @@ class TestURIIngestion:
# 8. Router configuration and state (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestRouterConfiguration:
+class TestRouterConfiguration:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_single_delivery_identity_per_router(self):
@@ -1419,8 +1427,8 @@ class TestRouterConfiguration:
# 9. End-to-end two-router communication (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestTwoRouterCommunication:
+class TestTwoRouterCommunication:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_bidirectional_protocol_delivery(self):
@@ -1556,8 +1564,8 @@ class TestTwoRouterCommunication:
# 10. Reticulum identity and destination basics (subprocess)
# ────────────────────────────────────────────────────────────────
-class TestReticulumPrimitives:
+class TestReticulumPrimitives:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_identity_creation_and_key_operations(self):

diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py
index ecaffa83..d4b3b3b1 100644
--- a/tests/backend/test_meshchat_coverage.py
+++ b/tests/backend/test_meshchat_coverage.py
@@ -258,10 +258,13 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
fake_identity.hash = bytes.fromhex("bb" * 16)
fake_identity.load_public_key.return_value = True
- with patch(
- "meshchatx.meshchat.AsyncUtils.run_async",
- side_effect=lambda coro: asyncio.create_task(coro),
- ), patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity):
+ with (
+ patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ),
+ patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
+ ):
await mock_app.on_websocket_data_received(
mock_client,
{
@@ -359,10 +362,12 @@ async def test_on_lxmf_sending_state_updated(mock_app):
with (
patch(
- "meshchatx.meshchat.convert_lxmf_message_to_dict", return_value={"h": "v"},
+ "meshchatx.meshchat.convert_lxmf_message_to_dict",
+ return_value={"h": "v"},
),
patch(
- "meshchatx.meshchat.convert_lxmf_state_to_string", return_value="delivered",
+ "meshchatx.meshchat.convert_lxmf_state_to_string",
+ return_value="delivered",
),
patch("meshchatx.meshchat.AsyncUtils.run_async") as mock_run_async,
):
@@ -414,7 +419,8 @@ def test_on_lxmf_sending_failed_no_propagation(mock_app):
mock_app.on_lxmf_sending_failed(mock_msg)
mock_app.on_lxmf_sending_state_updated.assert_called_once_with(
- mock_msg, context=None,
+ mock_msg,
+ context=None,
)
@@ -466,9 +472,12 @@ def test_convert_webm_opus_to_ogg_ffmpeg_fails(mock_app):
def test_convert_webm_opus_to_ogg_exception(mock_app):
webm_data = b"\x1a\x45\xdf\xa3" + b"\x00" * 100
- with patch("shutil.which", return_value="/usr/bin/ffmpeg"), patch(
- "subprocess.run",
- side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=30),
+ with (
+ patch("shutil.which", return_value="/usr/bin/ffmpeg"),
+ patch(
+ "subprocess.run",
+ side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=30),
+ ),
):
result = mock_app._convert_webm_opus_to_ogg(webm_data)
@@ -512,7 +521,9 @@ async def _run_send(app, destination_hash="aa" * 16, **kwargs):
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
await app.send_message(
- destination_hash=destination_hash, content="hi", **kwargs,
+ destination_hash=destination_hash,
+ content="hi",
+ **kwargs,
)
return fake_lxm

diff --git a/tests/backend/test_message_dao_extended.py b/tests/backend/test_message_dao_extended.py
index 5c6b97ff..e2a9c608 100644
--- a/tests/backend/test_message_dao_extended.py
+++ b/tests/backend/test_message_dao_extended.py
@@ -31,7 +31,8 @@ def test_upsert_lxmf_message(message_dao, mock_provider):
def test_get_lxmf_message_by_hash(message_dao, mock_provider):
message_dao.get_lxmf_message_by_hash("hash1")
mock_provider.fetchone.assert_called_with(
- "SELECT * FROM lxmf_messages WHERE hash = ?", ("hash1",),
+ "SELECT * FROM lxmf_messages WHERE hash = ?",
+ ("hash1",),
)

diff --git a/tests/backend/test_message_handler_extended.py b/tests/backend/test_message_handler_extended.py
index b11b1f03..24f7a08a 100644
--- a/tests/backend/test_message_handler_extended.py
+++ b/tests/backend/test_message_handler_extended.py
@@ -72,7 +72,10 @@ def test_get_conversations_base(mock_db):
def test_get_conversations_with_filters(mock_db):
handler = MessageHandler(mock_db)
handler.get_conversations(
- "local", search="test", filter_unread=True, filter_failed=True,
+ "local",
+ search="test",
+ filter_unread=True,
+ filter_failed=True,
)
args, _ = mock_db.provider.fetchall.call_args

diff --git a/tests/backend/test_nomadnet_downloader_boost.py b/tests/backend/test_nomadnet_downloader_boost.py
index 6898bbdf..2f7af2d5 100644
--- a/tests/backend/test_nomadnet_downloader_boost.py
+++ b/tests/backend/test_nomadnet_downloader_boost.py
@@ -29,7 +29,12 @@ def downloader():
on_failure = MagicMock()
on_progress = MagicMock()
return NomadnetDownloader(
- b"dest", "/path", "data", on_success, on_failure, on_progress,
+ b"dest",
+ "/path",
+ "data",
+ on_success,
+ on_failure,
+ on_progress,
)
@@ -120,7 +125,8 @@ async def test_download_uses_path_wait_cache_hit(downloader):
):
with patch.object(downloader, "link_established") as mock_established:
await downloader.download(
- path_lookup_timeout=5, link_establishment_timeout=5,
+ path_lookup_timeout=5,
+ link_establishment_timeout=5,
)
mock_established.assert_called_once_with(mock_link)

diff --git a/tests/backend/test_package_version_resolution.py b/tests/backend/test_package_version_resolution.py
index 0455127c..f9b48ff5 100644
--- a/tests/backend/test_package_version_resolution.py
+++ b/tests/backend/test_package_version_resolution.py
@@ -82,7 +82,9 @@ def test_app_info_dependency_keys_resolve_in_dev_env(package: str):
assert v != "unknown", f"{package} must resolve when installed"
-@pytest.mark.skipif(sys.version_info < (3, 13), reason="audioop-lts only on Python 3.13+")
+@pytest.mark.skipif(
+ sys.version_info < (3, 13), reason="audioop-lts only on Python 3.13+"
+)
def test_audioop_lts_resolves_when_applicable():
v = ReticulumMeshChat.get_package_version("audioop-lts")
assert v != "unknown"

diff --git a/tests/backend/test_property_based.py b/tests/backend/test_property_based.py
index 3ce9532a..58a0102a 100644
--- a/tests/backend/test_property_based.py
+++ b/tests/backend/test_property_based.py
@@ -268,7 +268,8 @@ def test_interface_config_parser_no_crash(text):
st.text(
min_size=1,
alphabet=st.characters(
- blacklist_categories=("Cc", "Cs"), blacklist_characters="[]",
+ blacklist_categories=("Cc", "Cs"),
+ blacklist_characters="[]",
),
).filter(lambda x: x.strip() == x and x),
min_size=1,
@@ -279,7 +280,8 @@ def test_interface_config_parser_no_crash(text):
st.text(
min_size=1,
alphabet=st.characters(
- blacklist_categories=("Cc", "Cs"), blacklist_characters="[]=",
+ blacklist_categories=("Cc", "Cs"),
+ blacklist_characters="[]=",
),
).filter(lambda x: x.strip() == x and x),
min_size=1,
@@ -733,7 +735,11 @@ def test_message_fields_have_attachments_robustness(fields_json):
lxmf_fields=st.dictionaries(
keys=st.integers(),
values=st.one_of(
- st.text(), st.binary(), st.integers(), st.booleans(), st.none(),
+ st.text(),
+ st.binary(),
+ st.integers(),
+ st.booleans(),
+ st.none(),
),
),
)
@@ -846,7 +852,12 @@ def test_convert_db_lxmf_message_to_dict_extended_robustness(
),
)
def test_lxmf_utils_conversions_robustness(
- state_val, method_val, title, content, timestamp, fields,
+ state_val,
+ method_val,
+ title,
+ content,
+ timestamp,
+ fields,
):
from unittest.mock import MagicMock
@@ -980,7 +991,12 @@ class TestCrashRecoveryMathProperties:
derandomize=True,
)
def test_system_entropy_always_finite(
- self, low_memory, config_missing, config_invalid, db_type, available_mem_mb,
+ self,
+ low_memory,
+ config_missing,
+ config_invalid,
+ db_type,
+ available_mem_mb,
):
"""Entropy and divergence must always be finite floats for any diagnosis."""
import math as m
@@ -1051,7 +1067,9 @@ class TestCrashRecoveryMathProperties:
@given(
counts=st.lists(
- st.integers(min_value=0, max_value=50), min_size=1, max_size=10,
+ st.integers(min_value=0, max_value=50),
+ min_size=1,
+ max_size=10,
),
)
@settings(derandomize=True, deadline=None, max_examples=50)

diff --git a/tests/backend/test_rncp_handler_extended.py b/tests/backend/test_rncp_handler_extended.py
index 6f564386..440f9900 100644
--- a/tests/backend/test_rncp_handler_extended.py
+++ b/tests/backend/test_rncp_handler_extended.py
@@ -32,7 +32,10 @@ def test_rncp_handler_init(rncp_handler, mock_reticulum, mock_identity):
@patch("meshchatx.src.backend.rncp_handler.RNS.Destination")
@patch("meshchatx.src.backend.rncp_handler.RNS.Reticulum")
def test_setup_receive_destination(
- mock_rns_reticulum, mock_dest, mock_identity_class, rncp_handler,
+ mock_rns_reticulum,
+ mock_dest,
+ mock_identity_class,
+ rncp_handler,
):
mock_rns_reticulum.identitypath = "/tmp/rns/identities"
mock_id_obj = MagicMock()

diff --git a/tests/backend/test_search_integration.py b/tests/backend/test_search_integration.py
new file mode 100644
index 00000000..3009503c
--- /dev/null
+++ b/tests/backend/test_search_integration.py
@@ -0,0 +1,91 @@
+"""Integration tests for conversation list search and announce search filtering."""
+
+import secrets
+import time
+
+from meshchatx.src.backend.announce_manager import (
+ filter_announced_dicts_by_search_query,
+)
+from meshchatx.src.backend.message_handler import MessageHandler
+from tests.backend.test_performance_hotpaths import make_message
+
+
+def test_get_conversations_search_finds_peer_via_older_message(db):
+ handler = MessageHandler(db)
+ peer = secrets.token_hex(16)
+ with db.provider:
+ m_old = make_message(peer, 1)
+ m_old["content"] = "unique_old_body_xyz"
+ m_old["title"] = "t1"
+ m_old["timestamp"] = time.time() - 100
+
+ m_new = make_message(peer, 2)
+ m_new["content"] = "latest"
+ m_new["title"] = "t2"
+ m_new["timestamp"] = time.time()
+
+ db.messages.upsert_lxmf_message(m_old)
+ db.messages.upsert_lxmf_message(m_new)
+
+ rows = handler.get_conversations("local", search="unique_old_body")
+ dest_hashes = {r["peer_hash"] for r in rows}
+ assert peer in dest_hashes
+
+
+def test_get_conversations_search_matches_custom_display_name(db):
+ handler = MessageHandler(db)
+ peer = secrets.token_hex(16)
+ with db.provider:
+ db.messages.upsert_lxmf_message(make_message(peer, 0))
+ db.announces.upsert_custom_display_name(peer, "CustomDisplayUniqueZ")
+
+ rows = handler.get_conversations("local", search="CustomDisplayUnique")
+ assert any(r["peer_hash"] == peer for r in rows)
+
+
+def test_filter_announced_dicts_by_search_query_display_name():
+ items = [
+ {
+ "display_name": "AlphaOnlyName",
+ "destination_hash": "c" * 32,
+ "identity_hash": "e" * 32,
+ },
+ {
+ "display_name": "BetaOtherName",
+ "destination_hash": "d" * 32,
+ "identity_hash": "e" * 32,
+ },
+ ]
+ out = filter_announced_dicts_by_search_query(items, "AlphaOnly")
+ assert len(out) == 1
+ assert out[0]["destination_hash"] == "c" * 32
+
+
+def test_filter_announced_dicts_by_search_query_custom_display_name():
+ items = [
+ {
+ "display_name": "Anonymous Peer",
+ "custom_display_name": "CustomNickUniqueSearch",
+ "destination_hash": "f" * 32,
+ "identity_hash": "1" * 32,
+ },
+ ]
+ out = filter_announced_dicts_by_search_query(items, "customnickunique")
+ assert len(out) == 1
+
+
+def test_filter_announced_dicts_by_search_query_destination_hash_substring():
+ dest = "ab" + "0" * 30
+ items = [
+ {"display_name": "X", "destination_hash": dest, "identity_hash": "y" * 32},
+ ]
+ out = filter_announced_dicts_by_search_query(items, "ab00")
+ assert len(out) == 1
+
+
+def test_filter_announced_dicts_by_search_query_case_insensitive():
+ items = [
+ {"display_name": "CamelCaseName", "destination_hash": "z" * 32},
+ ]
+ out = filter_announced_dicts_by_search_query(items, "camelcase")
+ assert len(out) == 1

diff --git a/tests/backend/test_security_fuzzing.py b/tests/backend/test_security_fuzzing.py
index e8ca4cdc..ac0a23ce 100644
--- a/tests/backend/test_security_fuzzing.py
+++ b/tests/backend/test_security_fuzzing.py
@@ -1394,7 +1394,10 @@ def test_nomadnet_page_archive_load_fuzzing(mock_app, archive_id):
),
)
def test_nomadnet_page_archive_add_fuzzing(
- mock_app, destination_hash, page_path, content,
+ mock_app,
+ destination_hash,
+ page_path,
+ content,
):
"""Fuzz nomadnet.page.archive.add WebSocket handler and archive_page."""
import asyncio
@@ -1465,7 +1468,10 @@ def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path):
),
)
def test_nomadnet_page_download_fuzzing(
- mock_app, destination_hash, page_path, field_data,
+ mock_app,
+ destination_hash,
+ page_path,
+ field_data,
):
"""Fuzz nomadnet.page.download WebSocket handler (page_path with backtick, field_data)."""
import asyncio
@@ -2284,7 +2290,8 @@ class TestLxmfFieldHardening:
pass
@settings(
- suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+ deadline=None,
)
@given(
fields_data=st.dictionaries(
@@ -2412,7 +2419,8 @@ class TestStrangerAttachmentBlocking:
"""Text-only messages from strangers are delivered normally."""
source_hash = os.urandom(16)
mock_msg = self._make_mock_message(
- source_hash=source_hash, with_attachments=False,
+ source_hash=source_hash,
+ with_attachments=False,
)
mock_app.config.block_attachments_from_strangers.get.return_value = True

diff --git a/tests/backend/test_telephone_api_json_contracts.py b/tests/backend/test_telephone_api_json_contracts.py
index 9f0a5829..4e821edc 100644
--- a/tests/backend/test_telephone_api_json_contracts.py
+++ b/tests/backend/test_telephone_api_json_contracts.py
@@ -71,14 +71,18 @@ class _Query:
@pytest.mark.asyncio
-async def test_api_v1_telephone_voicemail_status_json_contract(mock_rns_minimal, temp_dir):
+async def test_api_v1_telephone_voicemail_status_json_contract(
+ mock_rns_minimal, temp_dir
+):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
- handler = _find_handler(app_instance, "/api/v1/telephone/voicemail/status", "GET")
+ handler = _find_handler(
+ app_instance, "/api/v1/telephone/voicemail/status", "GET"
+ )
assert handler is not None
request = MagicMock()
response = await handler(request)
@@ -104,7 +108,9 @@ async def test_api_v1_telephone_voicemails_json_contract(mock_rns_minimal, temp_
@pytest.mark.asyncio
-async def test_api_v1_telephone_ringtones_list_json_contract(mock_rns_minimal, temp_dir):
+async def test_api_v1_telephone_ringtones_list_json_contract(
+ mock_rns_minimal, temp_dir
+):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
@@ -120,14 +126,18 @@ async def test_api_v1_telephone_ringtones_list_json_contract(mock_rns_minimal, t
@pytest.mark.asyncio
-async def test_api_v1_telephone_ringtones_status_json_contract(mock_rns_minimal, temp_dir):
+async def test_api_v1_telephone_ringtones_status_json_contract(
+ mock_rns_minimal, temp_dir
+):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
- handler = _find_handler(app_instance, "/api/v1/telephone/ringtones/status", "GET")
+ handler = _find_handler(
+ app_instance, "/api/v1/telephone/ringtones/status", "GET"
+ )
assert handler is not None
request = MagicMock()
request.query = _Query({})
@@ -154,7 +164,9 @@ async def test_api_v1_telephone_contacts_list_json_contract(mock_rns_minimal, te
@pytest.mark.asyncio
-async def test_api_v1_telephone_contacts_check_json_contract(mock_rns_minimal, temp_dir):
+async def test_api_v1_telephone_contacts_check_json_contract(
+ mock_rns_minimal, temp_dir
+):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,

diff --git a/tests/backend/test_telephone_manager_boost.py b/tests/backend/test_telephone_manager_boost.py
index cfb6e54f..d008ee8d 100644
--- a/tests/backend/test_telephone_manager_boost.py
+++ b/tests/backend/test_telephone_manager_boost.py
@@ -40,12 +40,16 @@ def test_init_telephone(mock_tel_class, tel_manager):
@patch("meshchatx.src.backend.telephone_manager.Telephone")
-def test_init_telephone_applies_config_audio_profile(mock_tel_class, mock_identity, tmp_path):
+def test_init_telephone_applies_config_audio_profile(
+ mock_tel_class, mock_identity, tmp_path
+):
storage_dir = tmp_path / "tel"
storage_dir.mkdir()
cfg = MagicMock()
cfg.telephone_audio_profile_id.get.return_value = 96
- tm = TelephoneManager(mock_identity, config_manager=cfg, storage_dir=str(storage_dir))
+ tm = TelephoneManager(
+ mock_identity, config_manager=cfg, storage_dir=str(storage_dir)
+ )
tm.init_telephone()
mock_tel_class.return_value.switch_profile.assert_called_with(96)

diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py
index 8da19e6b..fb3a4237 100644
--- a/tests/backend/test_translator_handler_extended.py
+++ b/tests/backend/test_translator_handler_extended.py
@@ -74,7 +74,10 @@ def test_translate_argos_cli(mock_run):
with patch("shutil.which", return_value="/usr/bin/argos-translate"):
result = handler.translate_text(
- "Hello", source_lang="en", target_lang="es", use_argos=True,
+ "Hello",
+ source_lang="en",
+ target_lang="es",
+ use_argos=True,
)
assert result["translated_text"] == "Hola"

diff --git a/tests/backend/test_voicemail_manager_boost.py b/tests/backend/test_voicemail_manager_boost.py
index 22d8dae6..969ae09c 100644
--- a/tests/backend/test_voicemail_manager_boost.py
+++ b/tests/backend/test_voicemail_manager_boost.py
@@ -29,7 +29,8 @@ def test_find_bundled_binary_not_frozen(voicemail_manager):
def test_find_espeak_shutil(voicemail_manager):
with patch(
- "shutil.which", side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None,
+ "shutil.which",
+ side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None,
):
path = voicemail_manager._find_espeak()
assert "espeak" in path

diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index ba2e749e..46810f08 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -1,4 +1,4 @@
-import { mount } from "@vue/test-utils";
+import { flushPromises, mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import CallPage from "@/components/call/CallPage.vue";
@@ -26,13 +26,48 @@ describe("CallPage.vue", () => {
if (url.includes("/api/v1/telephone/history")) return Promise.resolve({ data: { call_history: [] } });
if (url.includes("/api/v1/announces")) return Promise.resolve({ data: { announces: [] } });
if (url.includes("/api/v1/telephone/status")) return Promise.resolve({ data: { active_call: null } });
- if (url.includes("/api/v1/telephone/voicemail/status"))
- return Promise.resolve({ data: { has_espeak: false } });
- if (url.includes("/api/v1/telephone/ringtones/status"))
- return Promise.resolve({ data: { enabled: true } });
- if (url.includes("/api/v1/telephone/ringtones")) return Promise.resolve({ data: { ringtones: [] } });
+ if (url.includes("/api/v1/telephone/voicemail/status")) {
+ return Promise.resolve({
+ data: {
+ has_espeak: false,
+ has_ffmpeg: false,
+ is_recording: false,
+ is_greeting_recording: false,
+ has_greeting: false,
+ },
+ });
+ }
+ if (url.includes("/api/v1/telephone/voicemails")) {
+ return Promise.resolve({ data: { voicemails: [], unread_count: 0 } });
+ }
+ if (url.includes("/api/v1/telephone/ringtones/status")) {
+ return Promise.resolve({
+ data: {
+ has_custom_ringtone: false,
+ enabled: true,
+ filename: null,
+ id: null,
+ volume: 0.5,
+ },
+ });
+ }
+ if (url.includes("/api/v1/telephone/ringtones/") && url.includes("/audio")) {
+ return Promise.resolve({ data: new ArrayBuffer(0) });
+ }
+ if (url.includes("/api/v1/telephone/ringtones")) {
+ return Promise.resolve({ data: [] });
+ }
if (url.includes("/api/v1/telephone/audio-profiles"))
return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } });
+ if (url.includes("/api/v1/telephone/contacts/export")) {
+ return Promise.resolve({ data: { contacts: [] } });
+ }
+ if (url.includes("/api/v1/telephone/contacts/check/")) {
+ return Promise.resolve({ data: { is_contact: false, contact: null } });
+ }
+ if (url.includes("/api/v1/telephone/contacts")) {
+ return Promise.resolve({ data: { contacts: [], total_count: 0 } });
+ }
if (url.includes("/api/v1/contacts")) return Promise.resolve({ data: { contacts: [] } });
return Promise.resolve({ data: defaultData });
@@ -226,4 +261,85 @@ describe("CallPage.vue", () => {
await wrapper.vm.ensureWebAudio({ enabled: true });
expect(stop).toHaveBeenCalled();
});
+
+ it("getContacts maps telephone contacts list and hydrates visuals", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ const hydrate = vi.spyOn(wrapper.vm, "hydrateContactVisuals");
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ contacts: [{ id: 1, name: "Sam", remote_identity_hash: "ab".repeat(16) }],
+ total_count: 2,
+ },
+ });
+ await wrapper.vm.getContacts();
+ expect(wrapper.vm.contacts[0].name).toBe("Sam");
+ expect(hydrate).toHaveBeenCalled();
+ });
+
+ it("getVoicemails maps voicemails and unread_count", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ voicemails: [{ id: 9, remote_identity_hash: "cd".repeat(16), is_read: 0 }],
+ unread_count: 1,
+ },
+ });
+ await wrapper.vm.getVoicemails();
+ expect(wrapper.vm.voicemails).toHaveLength(1);
+ expect(wrapper.vm.unreadVoicemailsCount).toBe(1);
+ });
+
+ it("getRingtones stores API array on ringtones", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ axiosMock.get.mockResolvedValueOnce({
+ data: [
+ {
+ id: 1,
+ filename: "x.opus",
+ display_name: "Test",
+ is_primary: true,
+ created_at: "2024-01-01",
+ },
+ ],
+ });
+ await wrapper.vm.getRingtones();
+ expect(wrapper.vm.ringtones).toHaveLength(1);
+ expect(wrapper.vm.ringtones[0].filename).toBe("x.opus");
+ });
+
+ it("getVoicemailStatus stores voicemail status payload", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ has_espeak: true,
+ has_ffmpeg: false,
+ is_recording: false,
+ is_greeting_recording: false,
+ has_greeting: true,
+ },
+ });
+ await wrapper.vm.getVoicemailStatus();
+ expect(wrapper.vm.voicemailStatus.has_greeting).toBe(true);
+ });
+
+ it("getRingtoneStatus stores ringtone status payload", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ has_custom_ringtone: true,
+ enabled: true,
+ filename: "ring.opus",
+ id: 3,
+ volume: 0.8,
+ },
+ });
+ await wrapper.vm.getRingtoneStatus();
+ expect(wrapper.vm.ringtoneStatus.id).toBe(3);
+ expect(wrapper.vm.ringtoneStatus.volume).toBe(0.8);
+ });
});

diff --git a/tests/frontend/CommandPalette.test.js b/tests/frontend/CommandPalette.test.js
index 465d3db7..55e011dc 100644
--- a/tests/frontend/CommandPalette.test.js
+++ b/tests/frontend/CommandPalette.test.js
@@ -24,21 +24,28 @@ describe("CommandPalette.vue", () => {
GlobalEmitter.off("sync-propagation-node");
GlobalEmitter.off("toggle-orbit");
+ GlobalEmitter.off("toggle-falling-skies");
+ GlobalEmitter.off("toggle-snake");
+ GlobalEmitter.off("toggle-pong");
});
afterEach(() => {
delete window.api;
GlobalEmitter.off("sync-propagation-node");
GlobalEmitter.off("toggle-orbit");
+ GlobalEmitter.off("toggle-falling-skies");
+ GlobalEmitter.off("toggle-snake");
+ GlobalEmitter.off("toggle-pong");
vi.clearAllMocks();
});
- const mountCommandPalette = () => {
+ const mountCommandPalette = (route = { name: "messages" }) => {
return mount(CommandPalette, {
global: {
mocks: {
$t: (key) => key,
$router: routerMock,
+ $route: route,
},
stubs: {
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
@@ -248,7 +255,7 @@ describe("CommandPalette.vue", () => {
});
it("emits toggle-orbit event when orbit action is executed", async () => {
- const wrapper = mountCommandPalette();
+ const wrapper = mountCommandPalette({ name: "network-visualiser" });
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
@@ -260,6 +267,45 @@ describe("CommandPalette.vue", () => {
}
});
+ it("emits toggle-falling-skies when falling skies action is executed", async () => {
+ const wrapper = mountCommandPalette({ name: "network-visualiser" });
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-falling-skies");
+ if (result) {
+ wrapper.vm.executeResult(result);
+ expect(emitSpy).toHaveBeenCalledWith("toggle-falling-skies");
+ }
+ });
+
+ it("emits toggle-snake when mesh snake action is executed", async () => {
+ const wrapper = mountCommandPalette({ name: "network-visualiser" });
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-snake");
+ if (result) {
+ wrapper.vm.executeResult(result);
+ expect(emitSpy).toHaveBeenCalledWith("toggle-snake");
+ }
+ });
+
+ it("emits toggle-pong when mesh pong action is executed", async () => {
+ const wrapper = mountCommandPalette({ name: "network-visualiser" });
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-pong");
+ if (result) {
+ wrapper.vm.executeResult(result);
+ expect(emitSpy).toHaveBeenCalledWith("toggle-pong");
+ }
+ });
+
it("closes when ESC key is pressed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;

diff --git a/tests/frontend/ContactsPage.test.js b/tests/frontend/ContactsPage.test.js
index e7f1e0ed..dde3e272 100644
--- a/tests/frontend/ContactsPage.test.js
+++ b/tests/frontend/ContactsPage.test.js
@@ -1,4 +1,4 @@
-import { mount } from "@vue/test-utils";
+import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ContactsPage from "@/components/contacts/ContactsPage.vue";
import WebSocketConnection from "@/js/WebSocketConnection";
@@ -40,14 +40,17 @@ describe("ContactsPage.vue", () => {
},
});
}
- if (url === "/api/v1/telephone/contacts") {
- return Promise.resolve({ data: { contacts: [], total_count: 0 } });
- }
if (url === "/api/v1/telephone/contacts/export") {
return Promise.resolve({ data: { contacts: [] } });
}
if (url.startsWith("/api/v1/telephone/contacts/check/")) {
- return Promise.resolve({ data: {} });
+ return Promise.resolve({ data: { is_contact: false, contact: null } });
+ }
+ if (
+ url === "/api/v1/telephone/contacts" ||
+ (typeof url === "string" && url.startsWith("/api/v1/telephone/contacts?"))
+ ) {
+ return Promise.resolve({ data: { contacts: [], total_count: 0 } });
}
return Promise.resolve({ data: {} });
});
@@ -145,4 +148,42 @@ describe("ContactsPage.vue", () => {
expect(html).toContain("contacts.export_contacts");
expect(html).toContain("contacts.import_contacts");
});
+
+ it("getContacts maps total_count from telephone contacts API", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({
+ data: {
+ config: {
+ lxmf_address_hash: "a".repeat(32),
+ identity_public_key: "b".repeat(128),
+ },
+ },
+ });
+ }
+ if (
+ url === "/api/v1/telephone/contacts" ||
+ (typeof url === "string" && url.startsWith("/api/v1/telephone/contacts?"))
+ ) {
+ return Promise.resolve({
+ data: {
+ contacts: [{ id: 1, name: "One", remote_identity_hash: "c".repeat(32) }],
+ total_count: 42,
+ },
+ });
+ }
+ if (url === "/api/v1/telephone/contacts/export") {
+ return Promise.resolve({ data: { contacts: [] } });
+ }
+ if (url.startsWith("/api/v1/telephone/contacts/check/")) {
+ return Promise.resolve({ data: { is_contact: false, contact: null } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ const wrapper = mountPage();
+ await flushPromises();
+ expect(wrapper.vm.totalContactsCount).toBe(42);
+ expect(wrapper.vm.contacts).toHaveLength(1);
+ expect(wrapper.vm.contacts[0].name).toBe("One");
+ });
});

diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 2cf51034..67f1983c 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -14,6 +14,7 @@ describe("ConversationViewer.vue", () => {
let axiosMock;
beforeEach(() => {
+ GlobalState.config.theme = "light";
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
WebSocketConnection.connect();
@@ -526,6 +527,29 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("");
});
+ it("uses dark neutral waiting bubble when pathfinding in dark theme with default gray", () => {
+ GlobalState.config.theme = "dark";
+ GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
+ const wrapper = mountConversationViewer();
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: true,
+ lxmf_message: {
+ hash: "h-wait-dark",
+ state: "sending",
+ content: "hi",
+ destination_hash: "test-hash",
+ source_hash: "my-hash",
+ fields: {},
+ _pendingPathfinding: true,
+ },
+ };
+ expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({
+ "background-color": "#3f3f46",
+ color: "#ffffff",
+ });
+ });
+
it("marks inbound messages with markdown-content--inbound for link styling", async () => {
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
const wrapper = mountConversationViewer();
@@ -803,4 +827,705 @@ describe("ConversationViewer.vue", () => {
expect(drafts["a".repeat(32)]).toBe("save on leave");
});
});
+
+ describe("message visibility and empty bubble prevention", () => {
+ const makeChatItem = (overrides = {}) => ({
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ hash: `hash-${Math.random().toString(36).slice(2, 10)}`,
+ state: "delivered",
+ content: "",
+ destination_hash: "my-hash",
+ source_hash: "test-hash",
+ fields: {},
+ timestamp: 1700000000,
+ created_at: "2023-11-14T22:13:20.000Z",
+ ...overrides,
+ },
+ });
+
+ describe("hasRenderableContent", () => {
+ it("returns true for message with text content", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "hello", fields: {} })).toBe(true);
+ });
+
+ it("returns true for whitespace-padded text", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: " hi ", fields: {} })).toBe(true);
+ });
+
+ it("returns false for empty string content and no fields", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "", fields: {} })).toBe(false);
+ });
+
+ it("returns false for whitespace-only content and no fields", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: " ", fields: {} })).toBe(false);
+ });
+
+ it("returns false for null content and no fields", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: null, fields: {} })).toBe(false);
+ });
+
+ it("returns false for undefined content and no fields", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ fields: {} })).toBe(false);
+ });
+
+ it("returns false for undefined fields", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "" })).toBe(false);
+ });
+
+ it("returns false for null fields", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "", fields: null })).toBe(false);
+ });
+
+ it("returns true for image field", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "", fields: { image: { image_type: "png" } } })).toBe(
+ true
+ );
+ });
+
+ it("returns true for audio field", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "", fields: { audio: { audio_mode: 0x10 } } })).toBe(
+ true
+ );
+ });
+
+ it("returns true for file attachments", () => {
+ const wrapper = mountConversationViewer();
+ expect(
+ wrapper.vm.hasRenderableContent({
+ content: "",
+ fields: { file_attachments: [{ file_name: "doc.pdf" }] },
+ })
+ ).toBe(true);
+ });
+
+ it("returns true for telemetry", () => {
+ const wrapper = mountConversationViewer();
+ expect(
+ wrapper.vm.hasRenderableContent({
+ content: "",
+ fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
+ })
+ ).toBe(true);
+ });
+
+ it("returns true for telemetry stream", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "", fields: { telemetry_stream: [{}] } })).toBe(true);
+ });
+
+ it("returns true for location request command", () => {
+ const wrapper = mountConversationViewer();
+ expect(
+ wrapper.vm.hasRenderableContent({
+ content: "",
+ fields: { commands: [{ "0x01": true }] },
+ })
+ ).toBe(true);
+ });
+
+ it("returns true for location request via string key '1'", () => {
+ const wrapper = mountConversationViewer();
+ expect(
+ wrapper.vm.hasRenderableContent({
+ content: "",
+ fields: { commands: [{ 1: true }] },
+ })
+ ).toBe(true);
+ });
+
+ it("returns false for commands with no location request", () => {
+ const wrapper = mountConversationViewer();
+ expect(
+ wrapper.vm.hasRenderableContent({
+ content: "",
+ fields: { commands: [{ "0x99": true }] },
+ })
+ ).toBe(false);
+ });
+
+ it("returns false for empty commands array", () => {
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.hasRenderableContent({ content: "", fields: { commands: [] } })).toBe(false);
+ });
+
+ it("returns false for unknown/unsupported fields only", () => {
+ const wrapper = mountConversationViewer();
+ expect(
+ wrapper.vm.hasRenderableContent({
+ content: "",
+ fields: { some_future_field: { data: 42 }, another_unknown: "value" },
+ })
+ ).toBe(false);
+ });
+ });
+
+ describe("isImageOnlyMessage", () => {
+ it("returns true for image with no text", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({ fields: { image: { image_type: "png" } } });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
+ });
+
+ it("returns true for image with null content", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({ content: null, fields: { image: { image_type: "jpg" } } });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
+ });
+
+ it("returns true for image with whitespace-only content", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({ content: " ", fields: { image: { image_type: "png" } } });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
+ });
+
+ it("returns true for image with auto-generated filename caption", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "photo_2024.jpg",
+ fields: { image: { image_type: "jpg" } },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
+ });
+
+ it("returns false for image with real text caption", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "Look at this sunset!",
+ fields: { image: { image_type: "jpg" } },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false for image with reply", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ reply_to_hash: "abc123",
+ fields: { image: { image_type: "png" } },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false for image + audio", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ fields: { image: { image_type: "png" }, audio: { audio_mode: 0x10 } },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false for image + file attachments", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ fields: {
+ image: { image_type: "png" },
+ file_attachments: [{ file_name: "data.csv" }],
+ },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false for image + telemetry", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ fields: {
+ image: { image_type: "png" },
+ telemetry: { location: { latitude: 1, longitude: 2 } },
+ },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false for image + telemetry stream", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ fields: { image: { image_type: "png" }, telemetry_stream: [{}] },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false for image + location request command", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ fields: { image: { image_type: "png" }, commands: [{ "0x01": true }] },
+ });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns false when there is no image", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({ content: "just text", fields: {} });
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("returns true for outbound image-only", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ source_hash: "my-hash",
+ destination_hash: "test-hash",
+ fields: { image: { image_type: "png" } },
+ });
+ item.is_outbound = true;
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
+ });
+ });
+
+ describe("selectedPeerChatItems filtering", () => {
+ it("filters out messages with no renderable content", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({ content: "visible" }),
+ makeChatItem({ content: "", fields: {} }),
+ makeChatItem({ content: "", fields: { some_unknown: true } }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ expect(wrapper.vm.selectedPeerChatItems[0].lxmf_message.content).toBe("visible");
+ });
+
+ it("keeps image-only messages visible", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [makeChatItem({ fields: { image: { image_type: "png" } } })];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ });
+
+ it("keeps audio-only messages visible", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [makeChatItem({ fields: { audio: { audio_mode: 0x10 } } })];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ });
+
+ it("keeps telemetry messages visible when showTelemetryInChat is true", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.showTelemetryInChat = true;
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ });
+
+ it("filters out reactions", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [makeChatItem({ content: "reaction", is_reaction: true })];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
+ });
+
+ it("filters out empty messages even if state is delivered", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({ content: "", fields: {}, state: "delivered" }),
+ makeChatItem({ content: null, fields: {}, state: "delivered" }),
+ makeChatItem({ content: " ", fields: {}, state: "delivered" }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
+ });
+
+ it("filters out empty outbound messages", async () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "",
+ fields: {},
+ source_hash: "my-hash",
+ destination_hash: "test-hash",
+ });
+ item.is_outbound = true;
+ wrapper.vm.chatItems = [item];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
+ });
+
+ it("keeps file-only messages", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ fields: { file_attachments: [{ file_name: "readme.txt" }] },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ });
+
+ it("handles mixed visible and invisible messages correctly", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({ content: "text message" }),
+ makeChatItem({ content: "", fields: {} }),
+ makeChatItem({ fields: { image: { image_type: "png" } } }),
+ makeChatItem({ content: "", fields: { weird_field: true } }),
+ makeChatItem({ fields: { audio: { audio_mode: 0x10 } } }),
+ makeChatItem({ content: "", fields: {} }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(3);
+ });
+ });
+
+ describe("image strip grouping", () => {
+ it("groups 2+ consecutive image-only messages from same sender into imageGroup", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({ hash: "img1", fields: { image: { image_type: "png" } } }),
+ makeChatItem({ hash: "img2", fields: { image: { image_type: "jpg" } } }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(1);
+ expect(groups[0].type).toBe("imageGroup");
+ expect(groups[0].items).toHaveLength(2);
+ });
+
+ it("does not group a single image-only message", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [makeChatItem({ hash: "solo-img", fields: { image: { image_type: "png" } } })];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(1);
+ expect(groups[0].type).toBe("single");
+ });
+
+ it("does not merge images with real captions into strip", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ hash: "captioned1",
+ content: "Beautiful landscape",
+ fields: { image: { image_type: "png" } },
+ }),
+ makeChatItem({
+ hash: "captioned2",
+ content: "Another beautiful shot",
+ fields: { image: { image_type: "png" } },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.type === "single")).toBe(true);
+ });
+
+ it("groups images with filename captions into strip", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ hash: "fn1",
+ content: "photo_001.jpg",
+ fields: { image: { image_type: "jpg" } },
+ }),
+ makeChatItem({
+ hash: "fn2",
+ content: "photo_002.png",
+ fields: { image: { image_type: "png" } },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(1);
+ expect(groups[0].type).toBe("imageGroup");
+ });
+
+ it("does not merge images from different senders", async () => {
+ const wrapper = mountConversationViewer();
+ const inbound = makeChatItem({
+ hash: "in-img",
+ fields: { image: { image_type: "png" } },
+ });
+ inbound.is_outbound = false;
+ const outbound = makeChatItem({
+ hash: "out-img",
+ source_hash: "my-hash",
+ destination_hash: "test-hash",
+ fields: { image: { image_type: "png" } },
+ });
+ outbound.is_outbound = true;
+ wrapper.vm.chatItems = [inbound, outbound];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.type === "single")).toBe(true);
+ });
+
+ it("does not merge failed images into strip", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ hash: "fail-img",
+ state: "failed",
+ fields: { image: { image_type: "png" } },
+ }),
+ makeChatItem({
+ hash: "ok-img",
+ fields: { image: { image_type: "png" } },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.type === "single")).toBe(true);
+ });
+
+ it("does not merge spam images into strip", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ hash: "spam-img",
+ is_spam: true,
+ fields: { image: { image_type: "png" } },
+ }),
+ makeChatItem({ hash: "ok-img2", fields: { image: { image_type: "png" } } }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(2);
+ });
+
+ it("does not merge image with reply into strip", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ hash: "reply-img",
+ reply_to_hash: "some-hash",
+ fields: { image: { image_type: "png" } },
+ }),
+ makeChatItem({ hash: "plain-img", fields: { image: { image_type: "png" } } }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ expect(groups).toHaveLength(2);
+ });
+
+ it("caps image strip at 12 images", async () => {
+ const wrapper = mountConversationViewer();
+ const items = [];
+ for (let i = 0; i < 15; i++) {
+ items.push(makeChatItem({ hash: `strip-${i}`, fields: { image: { image_type: "png" } } }));
+ }
+ wrapper.vm.chatItems = items;
+ await wrapper.vm.$nextTick();
+
+ const groups = wrapper.vm.selectedPeerChatDisplayGroups;
+ const imgGroup = groups.find((g) => g.type === "imageGroup");
+ expect(imgGroup).toBeDefined();
+ expect(imgGroup.items.length).toBeLessThanOrEqual(12);
+ });
+ });
+
+ describe("isLikelyMultiImagePlaceholderCaption", () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = mountConversationViewer();
+ });
+
+ it("recognizes standard image filenames", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("IMG_20240101.png")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("screenshot.webp")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("image.heic")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("cat.gif")).toBe(true);
+ });
+
+ it("rejects non-image filenames", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("document.pdf")).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("video.mp4")).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("archive.zip")).toBe(false);
+ });
+
+ it("rejects normal text messages", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("hello world")).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("check out this image")).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("I sent you a .png file")).toBe(false);
+ });
+
+ it("rejects multiline text", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg\nmore text")).toBe(false);
+ });
+
+ it("rejects text with path separators", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("/home/user/photo.jpg")).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("C:\\Users\\photo.jpg")).toBe(false);
+ });
+
+ it("rejects text with special characters", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("<script>photo.jpg")).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("[photo].jpg")).toBe(false);
+ });
+
+ it("rejects null or empty", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption(null)).toBe(false);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("")).toBe(false);
+ });
+
+ it("rejects very long strings", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("a".repeat(250) + ".png")).toBe(false);
+ });
+
+ it("handles edge case extensions", () => {
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpeg")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.JPEG")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.PNG")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.avif")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.svg")).toBe(true);
+ expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.bmp")).toBe(true);
+ });
+ });
+
+ describe("shouldHideAutoImageCaption", () => {
+ it("hides filename caption on image message", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "photo_2024.jpg",
+ fields: { image: { image_type: "jpg" } },
+ });
+ expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(true);
+ });
+
+ it("does not hide real text on image message", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "Here is the picture from yesterday",
+ fields: { image: { image_type: "jpg" } },
+ });
+ expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
+ });
+
+ it("does not hide anything on non-image message", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({ content: "photo.jpg", fields: {} });
+ expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
+ });
+
+ it("does not hide empty content", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "",
+ fields: { image: { image_type: "png" } },
+ });
+ expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
+ });
+ });
+
+ describe("edge cases with multiple field combinations", () => {
+ it("image + text shows bubble (not image-only)", async () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "Check this out",
+ fields: { image: { image_type: "png" } },
+ });
+ wrapper.vm.chatItems = [item];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ expect(wrapper.vm.isImageOnlyMessage(wrapper.vm.selectedPeerChatItems[0])).toBe(false);
+ });
+
+ it("audio + text renders and is not image-only", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "Listen to this",
+ fields: { audio: { audio_mode: 0x10 } },
+ });
+ expect(wrapper.vm.hasRenderableContent(item.lxmf_message)).toBe(true);
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("file attachment with no text still shows", () => {
+ const wrapper = mountConversationViewer();
+ const msg = { content: "", fields: { file_attachments: [{ file_name: "a.zip" }] } };
+ expect(wrapper.vm.hasRenderableContent(msg)).toBe(true);
+ });
+
+ it("telemetry with text still shows", () => {
+ const wrapper = mountConversationViewer();
+ const msg = {
+ content: "My location",
+ fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
+ };
+ expect(wrapper.vm.hasRenderableContent(msg)).toBe(true);
+ });
+
+ it("image + audio + text is renderable but not image-only", () => {
+ const wrapper = mountConversationViewer();
+ const item = makeChatItem({
+ content: "multimedia",
+ fields: {
+ image: { image_type: "png" },
+ audio: { audio_mode: 0x10 },
+ },
+ });
+ expect(wrapper.vm.hasRenderableContent(item.lxmf_message)).toBe(true);
+ expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
+ });
+
+ it("message with only unknown future fields is hidden", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ content: "",
+ fields: { future_feature: { data: "something" } },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
+ });
+
+ it("message with multiple unknown fields is hidden", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [
+ makeChatItem({
+ content: "",
+ fields: {
+ sticker: { pack: "animals", id: 3 },
+ icon_appearance: { theme: "dark" },
+ },
+ }),
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
+ });
+
+ it("message with empty fields object is hidden", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.chatItems = [makeChatItem({ content: "", fields: {} })];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
+ });
+ });
+ });
});

diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index b2ccd9e9..e66c74b7 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -59,6 +59,48 @@ describe("MessagesPage.vue", () => {
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object));
});
+ it("debounces conversation search and sends search param to conversations API", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn(() => false);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.get.mockClear();
+
+ wrapper.vm.onConversationSearchChanged("findme");
+ await vi.advanceTimersByTimeAsync(100);
+ expect(axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations")).toHaveLength(0);
+
+ await vi.advanceTimersByTimeAsync(200);
+ const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations");
+ expect(convCalls.length).toBeGreaterThanOrEqual(1);
+ expect(convCalls[convCalls.length - 1][1].params.search).toBe("findme");
+ vi.useRealTimers();
+ });
+
+ it("debounces peers search and sends search param to announces API", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn(() => false);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.get.mockClear();
+
+ wrapper.vm.onPeersSearchChanged("peerq");
+ await vi.advanceTimersByTimeAsync(400);
+ expect(
+ axiosMock.get.mock.calls.filter(
+ (c) => c[0] === "/api/v1/announces" && c[1]?.params?.aspect === "lxmf.delivery"
+ )
+ ).toHaveLength(0);
+
+ await vi.advanceTimersByTimeAsync(200);
+ const ann = axiosMock.get.mock.calls.filter(
+ (c) => c[0] === "/api/v1/announces" && c[1]?.params?.aspect === "lxmf.delivery"
+ );
+ expect(ann.length).toBeGreaterThanOrEqual(1);
+ expect(ann[ann.length - 1][1].params.search).toBe("peerq");
+ vi.useRealTimers();
+ });
+
it("opens ingest paper message modal", async () => {
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();

diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index 34d6dbdb..7d3a8563 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -376,4 +376,50 @@ describe("NetworkVisualiser.vue", () => {
// After unmount, the cache should be empty or the reference should be cleared
expect(Object.keys(cacheRef).length).toBe(0);
});
+
+ it("does not add offline interfaces when showDisabledInterfaces is false", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.network = {
+ getPositions: vi.fn().mockReturnValue({}),
+ setOptions: vi.fn(),
+ on: vi.fn(),
+ destroy: vi.fn(),
+ };
+ wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
+ wrapper.vm.interfaces = [
+ { name: "eth_up", status: true, bitrate: 1000, txb: 0, rxb: 0 },
+ { name: "eth_down", status: false, bitrate: 0, txb: 0, rxb: 0 },
+ ];
+ wrapper.vm.showDisabledInterfaces = false;
+ await wrapper.vm.processVisualization();
+ expect(wrapper.vm.nodes.getIds()).toContain("eth_up");
+ expect(wrapper.vm.nodes.getIds()).not.toContain("eth_down");
+ });
+
+ it("keeps node positions from getPositions on subsequent layout passes", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ const getPositions = vi
+ .fn()
+ .mockReturnValueOnce({})
+ .mockReturnValue({
+ me: { x: 0, y: 0 },
+ eth0: { x: 301, y: 404 },
+ });
+ wrapper.vm.network = {
+ getPositions,
+ setOptions: vi.fn(),
+ on: vi.fn(),
+ destroy: vi.fn(),
+ };
+ wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
+ wrapper.vm.interfaces = [{ name: "eth0", status: true, bitrate: 1000, txb: 0, rxb: 0 }];
+ await wrapper.vm.processVisualization();
+ await wrapper.vm.processVisualization();
+ const n = wrapper.vm.nodes.get("eth0");
+ expect(n.x).toBe(301);
+ expect(n.y).toBe(404);
+ expect(getPositions.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
});

diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index 7656bfe7..71183644 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -63,6 +63,23 @@ describe("NomadNetworkPage.vue", () => {
expect(wrapper.text()).toContain("nomadnet.no_active_node");
});
+ it("debounces node search and passes search param to announces API", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn(() => false);
+ const wrapper = mountNomadNetworkPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.get.mockClear();
+
+ wrapper.vm.onNodesSearchChanged("nodequery");
+ await vi.advanceTimersByTimeAsync(500);
+ const calls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/announces");
+ expect(calls.length).toBeGreaterThanOrEqual(1);
+ const last = calls[calls.length - 1];
+ expect(last[1].params.aspect).toBe("nomadnetwork.node");
+ expect(last[1].params.search).toBe("nodequery");
+ vi.useRealTimers();
+ });
+
it("loads node when destinationHash prop is provided", async () => {
const destHash = "0123456789abcdef0123456789abcdef";
axiosMock.get.mockImplementation((url) => {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────